정수를 void 포인터 인자에 실어 보내기 — intptr_t 캐스팅 관용구

NOTE

pthread_create처럼 인자를 단 하나(void *)만 받는 스레드 함수나 콜백에 워커 ID, 인덱스, 파일 디스크립터 같은 정수 하나를 전달할 때, malloc 없이 포인터 변수 자체를 8바이트 값 그릇으로 활용하는 저수준 캐스팅 관용구((void *)(long)val)와 그 동작 원리를 정리한다.


0. 핵심 의문: “주소 0x00000003을 넣었는데 왜 세그폴트가 안 나는가?”

초보자나 고수준 언어 개발자가 이 패턴을 처음 볼 때 가장 많이 하는 질문이다:

“인덱스가 3일 때 포인터에 0x00000003을 넣는다면, 실제 메모리 3번지(0x3)를 가리키는 것 아닌가요? 3번지에 다른 쓰레기값이 있거나 접근 금지 영역이라 크래시(세그폴트)가 나야 정상 아닌가요?”

(1) 포인터는 단지 ‘64비트(8바이트) 크기의 메모리 칸’일 뿐이다

컴퓨터 아키텍처(CPU 레지스터와 RAM) 관점에서 볼 때, 포인터 변수 void *arg는 어떤 특별한 마법이 걸린 타입이 아니다. 단지 **64비트(8바이트) 크기의 정수를 담을 수 있는 메모리 칸(또는 RDI 레지스터)**에 불과하다.

void *arg = (void *)(long)3;을 실행하면, 그 변수 칸에는 2진수 비트 0x0000000000000003이 그대로 채워진다. 값을 넣는 행위 자체로는 CPU가 메모리 3번지를 방문하지 않는다.

(2) ‘쪽지에 적힌 집 주소’ vs ‘쪽지에 적힌 숫자 암호’ 비유

포인터의 위험 여부는 오직 **“역참조(Dereference, * 연산자)“**를 하느냐 마느냐에 달려 있다.

[일반적인 포인터 사용] — 주소 방문 (역참조 O)
메인 스레드 ──(쪽지에 '역삼동 100번지' 적어 전달)──> 워커 스레드
                                                    │
                                                    ▼ (*ptr 실행)
                                          실제 '역삼동 100번지'로 찾아가서
                                          대문을 열고 물건을 꺼냄!
 
[정수를 실어 보낸 트릭] — 숫자만 읽음 (역참조 X)
메인 스레드 ──(쪽지에 암호 숫자 '3' 적어 전달)──────> 워커 스레드
                                                    │
                                                    ▼ ((long)ptr 실행)
                                          쪽지에 적힌 숫자 '3'을 그대로 읽고 끝!
                                          어떤 집(메모리 번지)도 방문하지 않음!
  • *(long *)arg (역참조 시도): “0x00000003 번지 메모리의 문을 열고 8바이트를 읽어와라!” ➡️ 리눅스 가상 메모리에서 0x0 ~ 0xFFF 영역은 NULL 포인터 방어를 위해 접근이 엄격히 금지된 보호 구역(Trap Page)이므로, CPU 페이지 폴트가 발생하며 즉시 Segmentation Fault (SIGSEGV)로 프로세스가 강제 종료된다.
  • (long)arg (정수 변환): “내 손에 쥐고 있는 변수 칸의 64비트 비트열 0x3을 그대로 정수 3으로 읽어라!” ➡️ 메모리 0x3 번지 근처에는 발도 들이지 않으므로 100% 안전하다.

1. 재앙의 시나리오: 스레드 생성 루프에서 &for_i(주소)를 넘기면 왜 전멸하는가?

스레드를 여러 개 띄우면서 각 워커의 고유 번호(0, 1, 2, …)를 넘길 때 흔히 저지르는 **치명적인 동시성 버그(Race Condition)**가 바로 지역 변수의 주소를 넘기는 것이다.

/* ⚠️ 치명적 버그 코드 — 절대로 이렇게 작성하면 안 됨 */
for (int for_i = 0; for_i < 4; for_i++) {
    pthread_create(&tid[for_i], NULL, worker_thr, &for_i);
}

실행 타임라인 분석

시간(Time)  메인 스레드 (루프 실행)                    워커 스레드 0, 1, 2, 3 (OS 스케줄링)
  │
  ├─ 1. for_i = 0 대입
  ├─ 2. pthread_create(..., &for_i) 호출 ─────┐
  ├─ 3. 루프 바로 진행! for_i = 1 대입        │
  ├─ 4. pthread_create(..., &for_i) 호출 ──┐  │
  ├─ 5. 루프 바로 진행! for_i = 2 대입     │  │
  ├─ 6. 루프 계속 진행... for_i = 4 (종료)  │  │
  │                                          │  │ (OS가 뒤늦게 스레드들을 깨움)
  ▼                                          │  │
                                             ▼  │
                                      [워커 0 깨어남]: int my_id = *(int *)arg;
                                      👉 &for_i 메모리를 열어보니 이미 4가 적혀 있음!
                                                │
                                                ▼
                                      [워커 1 깨어남]: int my_id = *(int *)arg;
                                      👉 &for_i 메모리를 열어보니 역시 4가 적혀 있음!
  1. Race Condition: pthread_create는 스레드 생성을 요청하고 즉시 리턴한다. 메인 스레드는 CPU 수백 클럭 만에 루프를 다 돌아서 for_i가 최종값(예: 4)에 도달해 버린다.
  2. 동일 인덱스 중복: 뒤늦게 깨어난 모든 워커 스레드가 동일한 메모리 주소(&for_i)를 읽으므로, 모든 워커가 my_id = 4를 바라보게 된다. (워커 0, 1, 2, 3이 각자 전용 큐를 가지지 못하고 엉뚱한 큐 하나에 몰리거나 범위를 초과함).
  3. Dangling Pointer (스택 소멸): 만약 루프가 끝난 뒤 해당 함수가 리턴해버리면, for_i가 있던 스택 프레임 자체가 소멸한다. 워커 스레드가 뒤늦게 *(int *)arg를 실행하면 이미 다른 함수가 덮어쓴 쓰레기 데이터를 읽어 대참사가 난다.

2. 해결책: 포인터 그릇에 값 자체를 실어 보내기 (Pass by Value)

주소(&for_i)가 아니라, **그 순간의 정수 값 자체(for_i)**를 64비트 크기로 캐스팅하여 포인터 자리에 넣어서 넘긴다.

for (int for_i = 0; for_i < n_max_thread; for_i++) {
    /* 값(0, 1, 2, 3) 그 자체를 64비트 포인터 크기로 캐스팅해서 전달 */
    pst_workthr[for_i].mpthr = mpthr_init(
        sz_name, 1, mnfmng_work_thr, 
        (void *)(long)for_i,  /* 또는 (void *)(intptr_t)for_i */
        grace_time
    );
}
  • 함수 호출 시점에 for_i의 값(예: 0, 1, 2…)이 CPU 레지스터에 복사(Pass by Value)되어 스레드 인자 저장소로 들어간다.
  • 메인 스레드가 루프를 계속 돌아서 for_i가 4로 변하든, 함수가 리턴해서 스택이 날아가든, 각 워커 스레드는 생성 당시에 복사된 자기만의 고유한 정수 값을 완벽하게 보존한다.

3. 왜 (void *)for_i로 바로 캐스팅하면 안 되는가?

int for_i = 3;
void *pv_arg = (void *)for_i; // ⚠️ 컴파일러 경고 발생!

GCC/Clang 경고 메시지

warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]
  • 64비트 시스템(x86_64, AArch64)에서:
    • int는 4바이트 (32비트)
    • void *는 8바이트 (64비트)
  • 4바이트 정수를 8바이트 포인터로 한 번에 바꾸려고 하면, 컴파일러는 “상위 4바이트에 부호 확장을 해야 하는지, 0으로 채워야 하는지 모호하고 크기가 맞지 않는다”고 판단하여 경고를 띄운다.

해결: 2단계 캐스팅 (32비트 정수 ➡️ 64비트 정수 ➡️ 64비트 포인터)

int for_i (32비트) 
  └── (long) 또는 (intptr_t) ──> 64비트 정수로 크기 확장
        └── (void *) ──────────> 64비트 포인터로 타입 변환 (크기가 8바이트로 동일하므로 경고 없음)

4. (void *)(long) vs (void *)(intptr_t) — 실무 코드와 C 표준

실무 C 코드베이스를 보면 (long)을 거치는 코드와 (intptr_t)를 거치는 코드가 모두 존재한다. 둘의 차이는 플랫폼 모델과 C 표준 역사에 있다.

구분(void *)(long)val(void *)(intptr_t)val
등장 배경전통적인 유닉스/리눅스 C 관용구C99 표준 (<stdint.h>) 공식 도입
Linux / Unix (64-bit)LP64 모델: long = 8바이트, pointer = 8바이트 ➡️ 완벽하게 안전intptr_t = 8바이트 ➡️ 완벽하게 안전
Windows (64-bit)LLP64 모델: long = 4바이트, pointer = 8바이트 ➡️ ⚠️ 경고 발생!intptr_t = 8바이트 ➡️ 완벽하게 안전
권장 사항Linux 전용 고성능 통신 데몬 등 실무 코드에서 여전히 널리 사용됨OS/플랫폼 독립적인 모던 C 표준 권장 방식
  • 실무 리눅스 시스템 프로그래밍: 리눅스 x86_64 환경에서는 long이 항상 포인터와 동일한 64비트이므로 (void *)(long) 캐스팅이 오랫동안 관용구로 정착되어 널리 쓰인다.
  • 신규 작성 코드: 플랫폼 이식성을 보장해야 한다면 <stdint.h>를 include하고 intptr_t(부호 있음) 또는 uintptr_t(부호 없음)를 사용하는 것이 가장 이상적이다.

5. 워커 스레드 내부에서의 수신 (역참조 금지!)

워커 스레드 함수 본체에서는 보낼 때의 역순으로 포인터를 정수로 복원한다.

void *mnfmng_work_thr(void *pv_arg)
{
    /* ✅ 올바른 수신: 포인터 그릇에 든 8바이트 값을 그대로 long 정수로 복원 */
    long n_work_idx = (long)pv_arg;
    
    /* 또는 intptr_t를 사용한 경우 */
    /* int n_work_idx = (int)(intptr_t)pv_arg; */
 
    MPGLOG_INFO("워커 스레드 시작: worker_id=%ld\n", n_work_idx);
 
    while (g_b_run) {
        /* n_work_idx(0, 1, ...)를 사용해 자기 전용 큐에서 패킷 꺼내기 */
        process_queue(n_work_idx);
    }
    return NULL;
}

절대 하지 말아야 할 코드 (사망 플래그)

/* 💥 CRASH! SIGSEGV 발생 */
long n_work_idx = *(long *)pv_arg; 
  • (long *)pv_arg: 0x00000003을 주소로 취급.
  • *: 0x00000003 번지의 메모리를 읽으려 시도 ➡️ OS 세그폴트 시그널로 즉사.

6. 정수 전달 방식 3가지 비교 요약

방식코드 예시메모리 할당동기화 버그 위험해제(free) 책임
1. 주소 전달 (&i)pthread_create(..., &for_i);없음🚨 매우 위험
(Race Condition, 스택 소멸)
없음
2. 동적 할당 (malloc)int *p = malloc(sizeof(int));
*p = for_i;
pthread_create(..., p);
힙 할당
(오버헤드 발생)
안전함⚠️ 필수
(워커가 끝나며 free(p) 해야 함, 누수 위험)
3. 포인터 그릇 적재
(본 문서의 패턴)
pthread_create(..., (void *)(long)for_i);0바이트
(완전 무료)
완벽하게 안전
(값 복사 방식)
불필요
(할당한 적이 없으므로 해제도 없음)

7. 자주 하는 실수 & 체크리스트

  1. *(int *)arg 역참조 절대 금지
    • 포인터에 값을 실어 보냈다면, 받는 쪽에서도 반드시 역참조 기호(*) 없이 캐스팅만으로 꺼내야 한다.
  2. 구조체나 둘 이상의 값은 이 트릭 불가
    • 64비트(8바이트) 크기 그릇 하나이므로, int나 long, enum, 파일 디스크립터(fd)처럼 8바이트 이하의 단일 값에만 쓸 수 있다.
    • 전달할 값이 여러 개라면 정직하게 구조체를 정의하고 malloc하거나 전역 구조체 배열의 포인터를 넘겨야 한다.
  3. 부호(Sign) 확장 주의
    • 음수가 될 수 있는 값(int, ssize_t)은 intptr_t / long을 사용하고, 크기나 인덱스(size_t, uint32_t)처럼 양수만 쓰는 값은 uintptr_t / unsigned long을 사용하여 비트 왜곡을 방지한다.

관련 문서